/*
 * ConstrainedClass.java
 *
 * Created on 21-Aug-2007, 20:08:57
 *
 * Copyright (C)
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 2 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, write to the Free Software Foundation, Inc., 59 Temple
 * Place, Suite 330, Boston, MA 02111-1307 USA
 */
package com.grubby.databean.constraints.annotation;

import com.grubby.databean.AbstractDataBean;
import com.grubby.databean.constraints.validation.ConstraintValidator;
import com.grubby.databean.constraints.validation.ConstraintViolation;
import com.grubby.databean.constraints.validation.ValidatorFactory;
import com.grubby.databean.exception.ValidationException;

import javax.xml.validation.Validator;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * A helper class to provide access to and aid validation of Annotation based 
 * property constraints.
 * <P>
 * ConstrainedClass supports constraints that are added to the write
 * methods (setters) of DataBean properties. It does not support constraints 
 * added to either the field of the property or the method parameter of the setter.
 * <P>
 * The following shows an example of how to apply a
 * {@link com.grubby.databean.constraints.annotation.MaxLength MaxLength} constraint
 * to a property setter:
 * <PRE>
 * <CODE>
 * @MaxLength(25)
 * public void setStreet(String street) { ... }
 * </CODE>
 * </PRE>
 * For details about how to apply a particular constraint, see the documentation
 * for that constraint.
 * <P>
 * The following code snippet walks through some of the key methods in
 * ConstrainedClass to give and idea of what they do.
 * <BR/>
 * <CODE>
 * <PRE>
 * // Get a ConstrainedClass for our Address class
 * ConstrainedClass cc = ConstrainedClass.getConstrainedClassHelper(Address.class);
 *
 * // Get the set of names for properties (of Address) that have constraints
 * // applied
 * {@code Set<String> props = cc.getConstrainedProperties();}
 *
 * // now iterate through them
 * for (String propName : props) {
 *   // look up the constraints that are attached to the property whose name is
 *   // propName (e.g. could be the street property).
 *   // Note you don't need to call getConstrainedProperties before calling
 *   // getConstraints if you know the name of the property
 *   {@code Set<Annotation> constraints = cc.getConstraints(propName);}
 *
 *   // now iterate through the constraints
 *   for (Annotation constraint : constraints) {
 *     // we now have an individual constraint - e.g. MaxLength
 *     if (constraint instanceof MaxLength) {
 *       MaxLength ml = (MaxLength)constraint;
 *       int l = ml.value();
 *       // we now have the value for this MaxLength constraint (e.g. 25
 *       // characters)
 *     }
 *   }
 * }
 * </PRE>
 * </CODE>
 * A validate(object validateMe) utility method is also provided which checks 
 * all the constraints on the entire object and returns the List of violations
 * <P>
 * @see Constraint
 * @see IsOneOf
 * @see Length
 * @see Mandatory
 * @see MaxInclusive
 * @see MaxLength
 * @see MaxOccurs
 * @see MinInclusive
 * @see MinLength
 * @see MinOccurs
 * @see NotEmpty
 * @see Pattern
 * <P>
 * Note: This has been adapted from an Annotation processing example created by
 * Anders Holmgren of Sun Microsystems
 *
 * @author Anders Holmgren, with alterations by A. HARMEL-LAW.
 * E: andrew.harmel.law@gmail.com
 */
public class ConstrainedClass {

    /*
     * We don't want to be holding on top ConstrainedClass instances for classes
     * that are no longer used. To avoid this we use a WeakHashMap.
     * TODO: Make sure the WeakHashMap works as expected in that it holds on
     * to objects only as long as they are used - not easy to test.
     */
    private static Map<Class, ConstrainedClass> constrainedClasses 
            = Collections.synchronizedMap(new WeakHashMap<Class, ConstrainedClass>());
    private Map<String, Set<Annotation>> propertyConstraintMap;
    private Set<String> constrainedProperties;
    private Class<?> thisClass;

    private ConstrainedClass(Class<?> c) {
        thisClass = c;
        buildConstraintMap();
    }

    /**
     * Returns the ConstrainedClass for the given Class.
     * @param theClass the class to be wrapped
     * @return the ConstrainedClass that wraps the given class
     * @throws IllegalArgumentException if theClass is null
     */
    public static ConstrainedClass getConstrainedClass(Class<?> theClass) {
        
        if (theClass == null) {
            throw new IllegalArgumentException("null is not a legal value for theClass");
        }
        
        /*
         * We make an effort to avoid the same ConstrainedClass being created
         * more than once (i.e. on different threads) as there is potentially
         * a fair bit of reflection that goes on during construction. The
         * technique we use is to take a lock on theClass (e.g. Address.class)
         * and hold it while we create the associated ConstrainedClass.
         * Note this looks very much like the double checked lock trick, which
         * is known to be broken. However, in this case the class state that is
         * being changed are entries in a synchronised map.
         * If we removed the "synchronized (theClass)" statement, the code would
         * still be thread safe, but we could get cases where we were creating
         * unecessary copies of ConstrainedClass objects. As the process of
         * constructing an instance of ConstrainedClass for a given class should
         * always yield the same result, this is not a problem other than,
         * potentially, a temporary waste of cpu and memory.
         */
        ConstrainedClass cc = constrainedClasses.get(theClass);
        if (cc == null) {
            synchronized (theClass) {
                /*
                 * Now that we have the lock on the underlying class we just
                 * do a quick check that no-one got in ahead of us.
                 */
                cc = constrainedClasses.get(theClass);
                
                if (cc == null) {
                    /*
                     * OK still doesn't exist so create it and put it in the map.
                     */
                    cc = new ConstrainedClass(theClass);
                    constrainedClasses.put(theClass, cc);
                }
            }
        }

        return cc;
    }

    /**
     * Returns true if the annotation is a Constraint. I.e. contains the
     * Constraint marker annotation.
     * @param annotation the annotation to test
     * @throws IllegalArgumentException if the annotation is null
     */
    public static boolean isConstraint(Annotation annotation) {
        
        if (annotation == null) {
            throw new IllegalArgumentException("null is not a legal value for annotation");
        }
        
        return annotation.annotationType().isAnnotationPresent(Constraint.class);
    }

    /**
     * Returns the immutable set of constraints for a given property. The
     * constraints may be annotated on the setter method and may be anywhere 
     * within the class hierarchy.
     * <P>
     * The result is a set of constraints that must all be satisfied for the
     * property's value to be valid.
     * @param propertyName name of the property
     * @return the set (immutable) of constraints (annotations) or an empty set
     * if no constraints on that property
     * @throws IntrospectionException If there are problems accessing the property
     * @throws IllegalArgumentException If propertyName is null
     */
    public Set<Annotation> getConstraints(String propertyName) throws IntrospectionException {
        
        if (propertyName == null) {
            throw new IllegalArgumentException("null is not a legal value for propertyName");
        }
        
        Set<Annotation> c = propertyConstraintMap.get(propertyName);
        if (c == null) {
            throw new IntrospectionException("propertyName");
        }
        
        return c;
    }

    /**
     * Returns the set (immutable) of the properties that have constraints associated
     * with them.
     */
    public Set<String> getConstrainedProperties() {
        
        /*
         * Safe to return as it is immutable.
         */
        return constrainedProperties;
    }

    /**
     * Creates a map from property name to Set of constraints (for that property).
     * This is to save cpu cycles that would be incurred by reflecting each time.
     */
    private void buildConstraintMap() {
        
        Map<String, Set<Annotation>> m = new HashMap<String, Set<Annotation>>();
        Set<String> s = new HashSet<String>();

        try {
            // Get an array of PropertyDescriptors describing the editable
            // properties supported by this bean.
            PropertyDescriptor[] descs 
                    = Introspector.getBeanInfo(thisClass).getPropertyDescriptors();

            // For each PropertyDescriptor...
            for (PropertyDescriptor desc : descs) {
                // Get the name of the method
                String propName = desc.getName();

                Set<Annotation> constraints = new HashSet<Annotation>();

                /**
                 * Add any constraints that are on the getter method.
                 */
                Method read = desc.getReadMethod();
                if (read != null) {
                    addConstraints(constraints, thisClass, read.getName(), read.getParameterTypes());
                }

                /**
                 * Add any constraints that are on the setter method.
                 * Note these will take precedence for the same constraint
                 * types that were on the setter.
                 */
                Method write = desc.getWriteMethod();
                if (write != null) {
                    addConstraints(constraints, thisClass, write.getName(), write.getParameterTypes());
                }

                /*
                 * This is a fixed set of constraints so we turn it into an
                 * unmodifiable set. Note an the special empty set is used in
                 * the hope that this is more efficient.
                 */
                if (!constraints.isEmpty()) {
                    constraints = Collections.unmodifiableSet(constraints);
                    s.add(propName);
                } else {
                    constraints = Collections.emptySet();
                }
                m.put(propName, constraints);
            }
        } catch (IntrospectionException ex) {
            Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
        }

        /*
         * This is a fixed map of properties with constraints so we turn it into an
         * unmodifiable map. Note the special empty map is used in
         * the hope that this is more efficient.
         */
        if (!m.isEmpty()) {
            propertyConstraintMap = Collections.unmodifiableMap(m);
        } else {
            propertyConstraintMap = Collections.emptyMap();
        }
        
        /*
         * This is a fixed set of property names that have associated constraints
         * so we turn it into an unmodifiable set. Note the special empty set is
         * used in the hope that this is more efficient.
         */
        if (!s.isEmpty()) {
            constrainedProperties = Collections.unmodifiableSet(s);
        } else {
            constrainedProperties = Collections.emptySet();
        }
    }

    /**
     * Convenience method used by buildConstraintMap
     * @param constraints
     * @param thisClass
     * @param methodName
     * @param methodParameterTypes
     */
    private void addConstraints(Set<Annotation> constraints, Class<?> thisClass, 
            String methodName, Class<?>[] methodParameterTypes) {
        
        assert constraints != null;
        assert thisClass != null;
        assert methodName != null;
        assert methodParameterTypes != null;


        /*
         * TODO: handling of multiple instances of the same constraint.
         * It is possible for many instances of say MaxLength to be added to a
         * property (eg several times on one setter method throughout the 
         * inheritence hierarchy).
         * This can lead to problems in
         *   - error message reporting. eg several messages saying that the
         *     MaxLength has been violated and each giving a different value for
         *     max
         *   - integration w/ jsf etc. What will they make of multiple MaxLength
         *     validators?
         */
        try {
            Method method = thisClass.getDeclaredMethod(methodName, methodParameterTypes);

            /**
             * Add any constraints that are on the method
             */
            if (method != null) {
                addConstraints(constraints, method.getAnnotations());
            }
        } catch (NoSuchMethodException ex) {
            Logger.getLogger(ConstrainedClass.class.getName()).log(Level.FINE, "Not throwing this. Ignoring it.", ex);
        }

        /*
         * Alas we are not done yet. If we have overridden the setter method
         * then we may have annotations on the super class method. So call
         * recursively with our super class until we are at the top
         */
        Class<?> superClass = thisClass.getSuperclass();
        if (superClass != null) {
            addConstraints(constraints, superClass, methodName, methodParameterTypes);
        }
    }

    /**
     * Convenience method
     * @param constraints
     * @param annotations
     */
    private void addConstraints(Set<Annotation> constraints, Annotation[] annotations) {
        assert constraints != null;
        assert annotations != null;

        for (Annotation a : annotations) {
            if (isConstraint(a)) {
                constraints.add(a);
            }
        }
    }

    /**
     * Validate the bean object as requested.  Validation is based on the
     * constraint annotations applied to the bean
     * @param validateMe
     * @return A list of the ConstraintViolations on the bean object
     * @throws com.grubby.databean.exception.ValidationException
     */
    public List<ConstraintViolation> validate(Object validateMe) throws ValidationException {

        // TODO: Refactor to simplify
        List validationResults = new ArrayList();

        try {
            // Find the fields which are annotated
            PropertyDescriptor[] descs = Introspector.getBeanInfo(thisClass).getPropertyDescriptors();

            // For each PropertyDescriptor...
            for (PropertyDescriptor desc : descs) {

                String propName = desc.getName();

                // If the field is encapsulated
                Method read = desc.getReadMethod();
                if (read != null) {

                    Logger.getLogger(ConstrainedClass.class.getName()).log(Level.FINE, "Current Annotated Property: " + propName);

                    Object currentPropertyValue;
                    try {
                        Method getMethod = validateMe.getClass().getMethod(read.getName(), null);
                        currentPropertyValue = getMethod.invoke(validateMe, null);
                    } catch (IllegalAccessException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was an " + "IllegalAccessException thrown when getting the value of " + propName, ex);
                    } catch (IllegalArgumentException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was an " + "IllegalArgumentException thrown when getting the value of " + propName, ex);
                    } catch (InvocationTargetException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was an " + "InvocationTargetException thrown when getting the value of " + propName, ex);
                    } catch (NoSuchMethodException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was a NoSuchMethodException " + "thrown when getting the value of " + propName, ex);
                    } catch (SecurityException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was a SecurityException " + "thrown when getting the value of " + propName, ex);
                    }

                    Logger.getLogger(ConstrainedClass.class.getName()).log(Level.FINE, "Annotated property: " + propName);
                    Logger.getLogger(ConstrainedClass.class.getName()).log(Level.FINE, "Annotated property field value: " + currentPropertyValue);

                    // Find the annotations for the field
                    Set<Annotation> constraints;
                    try {
                        constraints = this.getConstraints(propName);
                    } catch (IntrospectionException ex) {
                        Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
                        throw new ValidationException("There was an " + "IntrospectionException thrown when looking up the " + "annotation constraints for " + propName, ex);
                    }

                    // For each annotation
                    Iterator<Annotation> constraintsIterator = constraints.iterator();
                    while (constraintsIterator.hasNext()) {

                        Annotation currentAnnotation = constraintsIterator.next();


                        ValidatorFactory myFactory = ValidatorFactory.getInstance();
                        ConstraintValidator myValidator;
                        try {
                            myValidator = myFactory.getValidatorFor(currentAnnotation.annotationType());
                        } catch (IllegalArgumentException ex) {
                            Logger.getLogger(AbstractDataBean.class.getName()).log(Level.SEVERE, null, ex);
                            throw new ValidationException("There was no validator for " + "this annotation: " + currentAnnotation, ex);
                        }

                        if (myValidator == null) {
                            throw new ValidationException("No validator found for " + currentAnnotation.toString());
                        }

                        // Check the annotation and if not null store the result
                        ConstraintViolation violation = myValidator.validate(currentPropertyValue, currentAnnotation);
                        if (violation != null) {
                            validationResults.add(violation);
                        }
                    }
                }
            }

        } catch (IntrospectionException ex) {
            Logger.getLogger(ConstrainedClass.class.getName()).log(Level.SEVERE, null, ex);
        }

        return validationResults;
    }
}